diff --git a/bin/lib/chat-filter.js b/bin/lib/chat-filter.js index 6a8f72382..b6f63bc46 100644 --- a/bin/lib/chat-filter.js +++ b/bin/lib/chat-filter.js @@ -1,29 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/chat-filter.ts, +// compiled to dist/lib/chat-filter.js. -/** - * Parse and filter Telegram chat IDs from the ALLOWED_CHAT_IDS env var. - * - * @param {string} [raw] - Comma-separated chat IDs (undefined = allow all) - * @returns {string[] | null} Array of allowed chat IDs, or null to allow all - */ -function parseAllowedChatIds(raw) { - if (!raw) return null; - return raw - .split(",") - .map((s) => s.trim()) - .filter(Boolean); -} - -/** - * Check whether a chat ID is allowed by the parsed allowlist. - * - * @param {string[] | null} allowedChats - Output of parseAllowedChatIds - * @param {string} chatId - The chat ID to check - * @returns {boolean} - */ -function isChatAllowed(allowedChats, chatId) { - return !allowedChats || allowedChats.includes(chatId); -} - -module.exports = { parseAllowedChatIds, isChatAllowed }; +module.exports = require("../../dist/lib/chat-filter"); diff --git a/bin/lib/resolve-openshell.js b/bin/lib/resolve-openshell.js index 1f80f8685..69c633689 100644 --- a/bin/lib/resolve-openshell.js +++ b/bin/lib/resolve-openshell.js @@ -1,58 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/resolve-openshell.ts, +// compiled to dist/lib/resolve-openshell.js. -const { execSync } = require("child_process"); -const fs = require("fs"); - -/** - * Resolve the openshell binary path. - * - * Checks `command -v` first (must return an absolute path to prevent alias - * injection), then falls back to common installation directories. - * - * @param {object} [opts] DI overrides for testing - * @param {string|null} [opts.commandVResult] Mock result (undefined = run real command) - * @param {function} [opts.checkExecutable] (path) => boolean - * @param {string} [opts.home] HOME override - * @returns {string|null} Absolute path to openshell, or null if not found - */ -function resolveOpenshell(opts = {}) { - const home = opts.home ?? process.env.HOME; - - // Step 1: command -v - if (opts.commandVResult === undefined) { - try { - const found = execSync("command -v openshell", { encoding: "utf-8" }).trim(); - if (found.startsWith("/")) return found; - } catch { - /* ignored */ - } - } else if (opts.commandVResult && opts.commandVResult.startsWith("/")) { - return opts.commandVResult; - } - - // Step 2: fallback candidates - const checkExecutable = - opts.checkExecutable || - ((p) => { - try { - fs.accessSync(p, fs.constants.X_OK); - return true; - } catch { - return false; - } - }); - - const candidates = [ - ...(home && home.startsWith("/") ? [`${home}/.local/bin/openshell`] : []), - "/usr/local/bin/openshell", - "/usr/bin/openshell", - ]; - for (const p of candidates) { - if (checkExecutable(p)) return p; - } - - return null; -} - -module.exports = { resolveOpenshell }; +module.exports = require("../../dist/lib/resolve-openshell"); diff --git a/bin/lib/version.js b/bin/lib/version.js index 2aabb638d..eec57e81f 100644 --- a/bin/lib/version.js +++ b/bin/lib/version.js @@ -1,43 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/version.ts, +// compiled to dist/lib/version.js. -/** - * Resolve the NemoClaw version from (in order): - * 1. `git describe --tags --match "v*"` — works in dev / source checkouts - * 2. `.version` file at repo root — stamped at publish time - * 3. `package.json` version — hard-coded fallback - */ - -const { execFileSync } = require("child_process"); -const path = require("path"); -const fs = require("fs"); - -const ROOT = path.resolve(__dirname, "..", ".."); - -function getVersion() { - // 1. Try git (available in dev clones and CI) - try { - const raw = execFileSync("git", ["describe", "--tags", "--match", "v*"], { - cwd: ROOT, - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - // raw looks like "v0.3.0" or "v0.3.0-4-gabcdef1" - if (raw) return raw.replace(/^v/, ""); - } catch { - // no git, or no matching tags — fall through - } - - // 2. Try .version file (stamped by prepublishOnly) - try { - const ver = fs.readFileSync(path.join(ROOT, ".version"), "utf-8").trim(); - if (ver) return ver; - } catch { - // not present — fall through - } - - // 3. Fallback to package.json - return require(path.join(ROOT, "package.json")).version; -} - -module.exports = { getVersion }; +module.exports = require("../../dist/lib/version"); diff --git a/src/lib/chat-filter.test.ts b/src/lib/chat-filter.test.ts new file mode 100644 index 000000000..d70e5d054 --- /dev/null +++ b/src/lib/chat-filter.test.ts @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import { parseAllowedChatIds, isChatAllowed } from "../../dist/lib/chat-filter"; + +describe("lib/chat-filter", () => { + describe("parseAllowedChatIds", () => { + it("returns null for undefined input", () => { + expect(parseAllowedChatIds(undefined)).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(parseAllowedChatIds("")).toBeNull(); + }); + + it("returns null for whitespace-only string", () => { + expect(parseAllowedChatIds(" , , ")).toBeNull(); + }); + + it("parses single chat ID", () => { + expect(parseAllowedChatIds("12345")).toEqual(["12345"]); + }); + + it("parses comma-separated chat IDs with whitespace", () => { + expect(parseAllowedChatIds("111, 222 ,333")).toEqual(["111", "222", "333"]); + }); + }); + + describe("isChatAllowed", () => { + it("allows all chats when allowed list is null", () => { + expect(isChatAllowed(null, "999")).toBe(true); + }); + + it("allows chat in the allowed list", () => { + expect(isChatAllowed(["111", "222"], "111")).toBe(true); + }); + + it("rejects chat not in the allowed list", () => { + expect(isChatAllowed(["111", "222"], "999")).toBe(false); + }); + }); +}); diff --git a/src/lib/chat-filter.ts b/src/lib/chat-filter.ts new file mode 100644 index 000000000..dfbcbd3b5 --- /dev/null +++ b/src/lib/chat-filter.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Parse a comma-separated list of allowed chat IDs. + * Returns null if the input is empty or undefined (meaning: accept all). + */ +export function parseAllowedChatIds(raw: string | undefined): string[] | null { + if (!raw) return null; + const ids = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return ids.length > 0 ? ids : null; +} + +/** + * Check whether a chat ID is allowed by the parsed allowlist. + * + * When `allowedChats` is null every chat is accepted (open mode). + */ +export function isChatAllowed(allowedChats: string[] | null, chatId: string): boolean { + return !allowedChats || allowedChats.includes(chatId); +} diff --git a/src/lib/resolve-openshell.test.ts b/src/lib/resolve-openshell.test.ts new file mode 100644 index 000000000..15b90cb8f --- /dev/null +++ b/src/lib/resolve-openshell.test.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from "vitest"; +import { resolveOpenshell } from "../../dist/lib/resolve-openshell"; + +describe("lib/resolve-openshell", () => { + it("returns command -v result when absolute path", () => { + expect(resolveOpenshell({ commandVResult: "/usr/bin/openshell" })).toBe("/usr/bin/openshell"); + }); + + it("rejects non-absolute command -v result (alias)", () => { + expect( + resolveOpenshell({ commandVResult: "openshell", checkExecutable: () => false }), + ).toBeNull(); + }); + + it("rejects alias definition from command -v", () => { + expect( + resolveOpenshell({ + commandVResult: "alias openshell='echo pwned'", + checkExecutable: () => false, + }), + ).toBeNull(); + }); + + it("falls back to ~/.local/bin when command -v fails", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/fakehome/.local/bin/openshell", + home: "/fakehome", + }), + ).toBe("/fakehome/.local/bin/openshell"); + }); + + it("falls back to /usr/local/bin", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/usr/local/bin/openshell", + }), + ).toBe("/usr/local/bin/openshell"); + }); + + it("falls back to /usr/bin", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => p === "/usr/bin/openshell", + }), + ).toBe("/usr/bin/openshell"); + }); + + it("prefers ~/.local/bin over /usr/local/bin", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: (p) => + p === "/fakehome/.local/bin/openshell" || p === "/usr/local/bin/openshell", + home: "/fakehome", + }), + ).toBe("/fakehome/.local/bin/openshell"); + }); + + it("returns null when openshell not found anywhere", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: () => false, + }), + ).toBeNull(); + }); + + it("skips home candidate when home is not absolute", () => { + expect( + resolveOpenshell({ + commandVResult: null, + checkExecutable: () => false, + home: "relative/path", + }), + ).toBeNull(); + }); + +}); diff --git a/src/lib/resolve-openshell.ts b/src/lib/resolve-openshell.ts new file mode 100644 index 000000000..b55fbfac8 --- /dev/null +++ b/src/lib/resolve-openshell.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execSync } from "node:child_process"; +import { accessSync, constants } from "node:fs"; + +export interface ResolveOpenshellOptions { + /** Mock result for `command -v` (undefined = run real command). */ + commandVResult?: string | null; + /** Override executable check (default: fs.accessSync X_OK). */ + checkExecutable?: (path: string) => boolean; + /** HOME directory override. */ + home?: string; +} + +/** + * Resolve the openshell binary path. + * + * Checks `command -v` first (must return an absolute path to prevent alias + * injection), then falls back to common installation directories. + */ +export function resolveOpenshell(opts: ResolveOpenshellOptions = {}): string | null { + const home = opts.home ?? process.env.HOME; + + // Step 1: command -v + if (opts.commandVResult === undefined) { + try { + const found = execSync("command -v openshell", { encoding: "utf-8" }).trim(); + if (found.startsWith("/")) return found; + } catch { + /* ignored */ + } + } else if (opts.commandVResult?.startsWith("/")) { + return opts.commandVResult; + } + + // Step 2: fallback candidates + const checkExecutable = + opts.checkExecutable ?? + ((p: string): boolean => { + try { + accessSync(p, constants.X_OK); + return true; + } catch { + return false; + } + }); + + const candidates = [ + ...(home?.startsWith("/") ? [`${home}/.local/bin/openshell`] : []), + "/usr/local/bin/openshell", + "/usr/bin/openshell", + ]; + for (const p of candidates) { + if (checkExecutable(p)) return p; + } + + return null; +} diff --git a/src/lib/version.test.ts b/src/lib/version.test.ts new file mode 100644 index 000000000..66bfbd577 --- /dev/null +++ b/src/lib/version.test.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { getVersion } from "../../dist/lib/version"; + +describe("lib/version", () => { + let testDir: string; + + beforeAll(() => { + testDir = mkdtempSync(join(tmpdir(), "version-test-")); + writeFileSync(join(testDir, "package.json"), JSON.stringify({ version: "1.2.3" })); + }); + + afterAll(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("falls back to package.json version when no git and no .version", () => { + expect(getVersion({ rootDir: testDir })).toBe("1.2.3"); + }); + + it("prefers .version file over package.json", () => { + writeFileSync(join(testDir, ".version"), "0.5.0-rc1\n"); + const result = getVersion({ rootDir: testDir }); + expect(result).toBe("0.5.0-rc1"); + rmSync(join(testDir, ".version")); + }); + + it("returns a string", () => { + expect(typeof getVersion({ rootDir: testDir })).toBe("string"); + }); +}); diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 000000000..5dd9ca00f --- /dev/null +++ b/src/lib/version.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface VersionOptions { + /** Override the repo root directory. */ + rootDir?: string; +} + +/** + * Resolve the NemoClaw version from (in order): + * 1. `git describe --tags --match "v*"` — works in dev / source checkouts + * 2. `.version` file at repo root — stamped at publish time + * 3. `package.json` version — hard-coded fallback + */ +export function getVersion(opts: VersionOptions = {}): string { + // Compiled location: dist/lib/version.js → repo root is 2 levels up + const root = opts.rootDir ?? join(__dirname, "..", ".."); + + // 1. Try git (available in dev clones and CI) + try { + const raw = execFileSync("git", ["describe", "--tags", "--match", "v*"], { + cwd: root, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + if (raw) return raw.replace(/^v/, ""); + } catch { + // no git, or no matching tags — fall through + } + + // 2. Try .version file (stamped by prepublishOnly) + const versionFile = join(root, ".version"); + if (existsSync(versionFile)) { + const ver = readFileSync(versionFile, "utf-8").trim(); + if (ver) return ver; + } + + // 3. Fallback to package.json + const raw = readFileSync(join(root, "package.json"), "utf-8"); + const pkg = JSON.parse(raw) as { version: string }; + return pkg.version; +}