Skip to content

Commit 02ea637

Browse files
prekshivyasPrekshi Vyasclaudecv
authored andcommitted
refactor(cli): migrate remaining CJS modules to TypeScript (NVIDIA#1306)
## Summary Closes NVIDIA#1298. Part of NVIDIA#924 shell consolidation. - **resolve-openshell.js** → TS migration with `ResolveOpenshellOptions` interface; bin/lib shim re-exports from compiled dist - **version.ts** → new module reading root package.json + `git describe`; wired into `bin/nemoclaw.js` for `--version` and help (output unchanged — uses plain semver) - **chat-filter.ts** → new utility extracting `ALLOWED_CHAT_IDS` parsing from telegram-bridge; wired into `scripts/telegram-bridge.js` Each module follows the established pattern: 1. TS source in `nemoclaw/src/lib/` with flat interfaces 2. Thin CJS re-export shim in `bin/lib/` → `require("../../nemoclaw/dist/lib/...")` 3. Co-located tests in `src/lib/*.test.ts` importing from `../../dist/lib/` for coverage attribution ## Test plan - [x] `npx vitest run --project plugin` — 216 tests pass (includes new lib tests) - [x] `npx vitest run --project cli` — 336 tests pass (shims work transparently) - [x] `npx eslint nemoclaw/src/lib/` — clean - [x] `node bin/nemoclaw.js --version` — output unchanged (`nemoclaw v0.1.0`) - [x] Pre-commit hooks pass (prettier, eslint, gitleaks, vitest, tsc) 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added utilities for chat allowlist handling, openshell binary resolution, and robust version detection. * **Refactor** * Consolidated runtime implementations into the compiled distribution to streamline module surfaces. * **Tests** * Added comprehensive tests covering chat filtering, shell resolution fallbacks, and version-detection behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Prekshi Vyas <prekshivyas@nvidia.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Carlos Villela <cvillela@nvidia.com>
1 parent eb6f392 commit 02ea637

File tree

9 files changed

+305
-121
lines changed

9 files changed

+305
-121
lines changed

bin/lib/chat-filter.js

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,7 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Thin re-export shim — the implementation lives in src/lib/chat-filter.ts,
5+
// compiled to dist/lib/chat-filter.js.
36

4-
/**
5-
* Parse and filter Telegram chat IDs from the ALLOWED_CHAT_IDS env var.
6-
*
7-
* @param {string} [raw] - Comma-separated chat IDs (undefined = allow all)
8-
* @returns {string[] | null} Array of allowed chat IDs, or null to allow all
9-
*/
10-
function parseAllowedChatIds(raw) {
11-
if (!raw) return null;
12-
return raw
13-
.split(",")
14-
.map((s) => s.trim())
15-
.filter(Boolean);
16-
}
17-
18-
/**
19-
* Check whether a chat ID is allowed by the parsed allowlist.
20-
*
21-
* @param {string[] | null} allowedChats - Output of parseAllowedChatIds
22-
* @param {string} chatId - The chat ID to check
23-
* @returns {boolean}
24-
*/
25-
function isChatAllowed(allowedChats, chatId) {
26-
return !allowedChats || allowedChats.includes(chatId);
27-
}
28-
29-
module.exports = { parseAllowedChatIds, isChatAllowed };
7+
module.exports = require("../../dist/lib/chat-filter");

bin/lib/resolve-openshell.js

Lines changed: 4 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,7 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Thin re-export shim — the implementation lives in src/lib/resolve-openshell.ts,
5+
// compiled to dist/lib/resolve-openshell.js.
36

4-
const { execSync } = require("child_process");
5-
const fs = require("fs");
6-
7-
/**
8-
* Resolve the openshell binary path.
9-
*
10-
* Checks `command -v` first (must return an absolute path to prevent alias
11-
* injection), then falls back to common installation directories.
12-
*
13-
* @param {object} [opts] DI overrides for testing
14-
* @param {string|null} [opts.commandVResult] Mock result (undefined = run real command)
15-
* @param {function} [opts.checkExecutable] (path) => boolean
16-
* @param {string} [opts.home] HOME override
17-
* @returns {string|null} Absolute path to openshell, or null if not found
18-
*/
19-
function resolveOpenshell(opts = {}) {
20-
const home = opts.home ?? process.env.HOME;
21-
22-
// Step 1: command -v
23-
if (opts.commandVResult === undefined) {
24-
try {
25-
const found = execSync("command -v openshell", { encoding: "utf-8" }).trim();
26-
if (found.startsWith("/")) return found;
27-
} catch {
28-
/* ignored */
29-
}
30-
} else if (opts.commandVResult && opts.commandVResult.startsWith("/")) {
31-
return opts.commandVResult;
32-
}
33-
34-
// Step 2: fallback candidates
35-
const checkExecutable =
36-
opts.checkExecutable ||
37-
((p) => {
38-
try {
39-
fs.accessSync(p, fs.constants.X_OK);
40-
return true;
41-
} catch {
42-
return false;
43-
}
44-
});
45-
46-
const candidates = [
47-
...(home && home.startsWith("/") ? [`${home}/.local/bin/openshell`] : []),
48-
"/usr/local/bin/openshell",
49-
"/usr/bin/openshell",
50-
];
51-
for (const p of candidates) {
52-
if (checkExecutable(p)) return p;
53-
}
54-
55-
return null;
56-
}
57-
58-
module.exports = { resolveOpenshell };
7+
module.exports = require("../../dist/lib/resolve-openshell");

bin/lib/version.js

Lines changed: 4 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,7 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Thin re-export shim — the implementation lives in src/lib/version.ts,
5+
// compiled to dist/lib/version.js.
36

4-
/**
5-
* Resolve the NemoClaw version from (in order):
6-
* 1. `git describe --tags --match "v*"` — works in dev / source checkouts
7-
* 2. `.version` file at repo root — stamped at publish time
8-
* 3. `package.json` version — hard-coded fallback
9-
*/
10-
11-
const { execFileSync } = require("child_process");
12-
const path = require("path");
13-
const fs = require("fs");
14-
15-
const ROOT = path.resolve(__dirname, "..", "..");
16-
17-
function getVersion() {
18-
// 1. Try git (available in dev clones and CI)
19-
try {
20-
const raw = execFileSync("git", ["describe", "--tags", "--match", "v*"], {
21-
cwd: ROOT,
22-
encoding: "utf-8",
23-
stdio: ["ignore", "pipe", "ignore"],
24-
}).trim();
25-
// raw looks like "v0.3.0" or "v0.3.0-4-gabcdef1"
26-
if (raw) return raw.replace(/^v/, "");
27-
} catch {
28-
// no git, or no matching tags — fall through
29-
}
30-
31-
// 2. Try .version file (stamped by prepublishOnly)
32-
try {
33-
const ver = fs.readFileSync(path.join(ROOT, ".version"), "utf-8").trim();
34-
if (ver) return ver;
35-
} catch {
36-
// not present — fall through
37-
}
38-
39-
// 3. Fallback to package.json
40-
return require(path.join(ROOT, "package.json")).version;
41-
}
42-
43-
module.exports = { getVersion };
7+
module.exports = require("../../dist/lib/version");

src/lib/chat-filter.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { describe, it, expect } from "vitest";
5+
import { parseAllowedChatIds, isChatAllowed } from "../../dist/lib/chat-filter";
6+
7+
describe("lib/chat-filter", () => {
8+
describe("parseAllowedChatIds", () => {
9+
it("returns null for undefined input", () => {
10+
expect(parseAllowedChatIds(undefined)).toBeNull();
11+
});
12+
13+
it("returns null for empty string", () => {
14+
expect(parseAllowedChatIds("")).toBeNull();
15+
});
16+
17+
it("returns null for whitespace-only string", () => {
18+
expect(parseAllowedChatIds(" , , ")).toBeNull();
19+
});
20+
21+
it("parses single chat ID", () => {
22+
expect(parseAllowedChatIds("12345")).toEqual(["12345"]);
23+
});
24+
25+
it("parses comma-separated chat IDs with whitespace", () => {
26+
expect(parseAllowedChatIds("111, 222 ,333")).toEqual(["111", "222", "333"]);
27+
});
28+
});
29+
30+
describe("isChatAllowed", () => {
31+
it("allows all chats when allowed list is null", () => {
32+
expect(isChatAllowed(null, "999")).toBe(true);
33+
});
34+
35+
it("allows chat in the allowed list", () => {
36+
expect(isChatAllowed(["111", "222"], "111")).toBe(true);
37+
});
38+
39+
it("rejects chat not in the allowed list", () => {
40+
expect(isChatAllowed(["111", "222"], "999")).toBe(false);
41+
});
42+
});
43+
});

src/lib/chat-filter.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/**
5+
* Parse a comma-separated list of allowed chat IDs.
6+
* Returns null if the input is empty or undefined (meaning: accept all).
7+
*/
8+
export function parseAllowedChatIds(raw: string | undefined): string[] | null {
9+
if (!raw) return null;
10+
const ids = raw
11+
.split(",")
12+
.map((s) => s.trim())
13+
.filter(Boolean);
14+
return ids.length > 0 ? ids : null;
15+
}
16+
17+
/**
18+
* Check whether a chat ID is allowed by the parsed allowlist.
19+
*
20+
* When `allowedChats` is null every chat is accepted (open mode).
21+
*/
22+
export function isChatAllowed(allowedChats: string[] | null, chatId: string): boolean {
23+
return !allowedChats || allowedChats.includes(chatId);
24+
}

src/lib/resolve-openshell.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { describe, it, expect } from "vitest";
5+
import { resolveOpenshell } from "../../dist/lib/resolve-openshell";
6+
7+
describe("lib/resolve-openshell", () => {
8+
it("returns command -v result when absolute path", () => {
9+
expect(resolveOpenshell({ commandVResult: "/usr/bin/openshell" })).toBe("/usr/bin/openshell");
10+
});
11+
12+
it("rejects non-absolute command -v result (alias)", () => {
13+
expect(
14+
resolveOpenshell({ commandVResult: "openshell", checkExecutable: () => false }),
15+
).toBeNull();
16+
});
17+
18+
it("rejects alias definition from command -v", () => {
19+
expect(
20+
resolveOpenshell({
21+
commandVResult: "alias openshell='echo pwned'",
22+
checkExecutable: () => false,
23+
}),
24+
).toBeNull();
25+
});
26+
27+
it("falls back to ~/.local/bin when command -v fails", () => {
28+
expect(
29+
resolveOpenshell({
30+
commandVResult: null,
31+
checkExecutable: (p) => p === "/fakehome/.local/bin/openshell",
32+
home: "/fakehome",
33+
}),
34+
).toBe("/fakehome/.local/bin/openshell");
35+
});
36+
37+
it("falls back to /usr/local/bin", () => {
38+
expect(
39+
resolveOpenshell({
40+
commandVResult: null,
41+
checkExecutable: (p) => p === "/usr/local/bin/openshell",
42+
}),
43+
).toBe("/usr/local/bin/openshell");
44+
});
45+
46+
it("falls back to /usr/bin", () => {
47+
expect(
48+
resolveOpenshell({
49+
commandVResult: null,
50+
checkExecutable: (p) => p === "/usr/bin/openshell",
51+
}),
52+
).toBe("/usr/bin/openshell");
53+
});
54+
55+
it("prefers ~/.local/bin over /usr/local/bin", () => {
56+
expect(
57+
resolveOpenshell({
58+
commandVResult: null,
59+
checkExecutable: (p) =>
60+
p === "/fakehome/.local/bin/openshell" || p === "/usr/local/bin/openshell",
61+
home: "/fakehome",
62+
}),
63+
).toBe("/fakehome/.local/bin/openshell");
64+
});
65+
66+
it("returns null when openshell not found anywhere", () => {
67+
expect(
68+
resolveOpenshell({
69+
commandVResult: null,
70+
checkExecutable: () => false,
71+
}),
72+
).toBeNull();
73+
});
74+
75+
it("skips home candidate when home is not absolute", () => {
76+
expect(
77+
resolveOpenshell({
78+
commandVResult: null,
79+
checkExecutable: () => false,
80+
home: "relative/path",
81+
}),
82+
).toBeNull();
83+
});
84+
85+
});

src/lib/resolve-openshell.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { execSync } from "node:child_process";
5+
import { accessSync, constants } from "node:fs";
6+
7+
export interface ResolveOpenshellOptions {
8+
/** Mock result for `command -v` (undefined = run real command). */
9+
commandVResult?: string | null;
10+
/** Override executable check (default: fs.accessSync X_OK). */
11+
checkExecutable?: (path: string) => boolean;
12+
/** HOME directory override. */
13+
home?: string;
14+
}
15+
16+
/**
17+
* Resolve the openshell binary path.
18+
*
19+
* Checks `command -v` first (must return an absolute path to prevent alias
20+
* injection), then falls back to common installation directories.
21+
*/
22+
export function resolveOpenshell(opts: ResolveOpenshellOptions = {}): string | null {
23+
const home = opts.home ?? process.env.HOME;
24+
25+
// Step 1: command -v
26+
if (opts.commandVResult === undefined) {
27+
try {
28+
const found = execSync("command -v openshell", { encoding: "utf-8" }).trim();
29+
if (found.startsWith("/")) return found;
30+
} catch {
31+
/* ignored */
32+
}
33+
} else if (opts.commandVResult?.startsWith("/")) {
34+
return opts.commandVResult;
35+
}
36+
37+
// Step 2: fallback candidates
38+
const checkExecutable =
39+
opts.checkExecutable ??
40+
((p: string): boolean => {
41+
try {
42+
accessSync(p, constants.X_OK);
43+
return true;
44+
} catch {
45+
return false;
46+
}
47+
});
48+
49+
const candidates = [
50+
...(home?.startsWith("/") ? [`${home}/.local/bin/openshell`] : []),
51+
"/usr/local/bin/openshell",
52+
"/usr/bin/openshell",
53+
];
54+
for (const p of candidates) {
55+
if (checkExecutable(p)) return p;
56+
}
57+
58+
return null;
59+
}

src/lib/version.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
5+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
6+
import { join } from "node:path";
7+
import { tmpdir } from "node:os";
8+
import { getVersion } from "../../dist/lib/version";
9+
10+
describe("lib/version", () => {
11+
let testDir: string;
12+
13+
beforeAll(() => {
14+
testDir = mkdtempSync(join(tmpdir(), "version-test-"));
15+
writeFileSync(join(testDir, "package.json"), JSON.stringify({ version: "1.2.3" }));
16+
});
17+
18+
afterAll(() => {
19+
rmSync(testDir, { recursive: true, force: true });
20+
});
21+
22+
it("falls back to package.json version when no git and no .version", () => {
23+
expect(getVersion({ rootDir: testDir })).toBe("1.2.3");
24+
});
25+
26+
it("prefers .version file over package.json", () => {
27+
writeFileSync(join(testDir, ".version"), "0.5.0-rc1\n");
28+
const result = getVersion({ rootDir: testDir });
29+
expect(result).toBe("0.5.0-rc1");
30+
rmSync(join(testDir, ".version"));
31+
});
32+
33+
it("returns a string", () => {
34+
expect(typeof getVersion({ rootDir: testDir })).toBe("string");
35+
});
36+
});

0 commit comments

Comments
 (0)