diff --git a/nemoclaw/src/banner.test.ts b/nemoclaw/src/banner.test.ts deleted file mode 100644 index 512345759e3..00000000000 --- a/nemoclaw/src/banner.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; -import { renderBox } from "./banner.js"; - -describe("renderBox (plugin)", () => { - it("renders the registration banner with equal-width rows", () => { - const lines = renderBox( - [ - " NemoClaw registered", - null, - " Endpoint: https://integrate.api.nvidia.com/v1", - " Provider: NVIDIA Endpoints", - " Model: nvidia/nemotron-3-super-120b-a12b", - " Slash: /nemoclaw", - ], - { columns: 100 }, - ); - - expect(new Set(lines.map((line) => line.length)).size).toBe(1); - expect(lines[3]).toMatch(/ {2,}│$/); - }); - - it("expands for long endpoint URLs", () => { - const endpoint = - " Endpoint: https://very-long-custom-endpoint.internal.nvidia.com/v1/completions"; - const lines = renderBox([endpoint], { columns: 120 }); - - expect(lines[1]).toContain("very-long-custom-endpoint.internal.nvidia.com"); - expect(lines[1]).toMatch(/ {2}│$/); - }); - - it("truncates in narrow terminals but keeps a border safety gap", () => { - const lines = renderBox(["x".repeat(200)], { columns: 80 }); - - expect(lines.every((line) => line.length <= 80)).toBe(true); - expect(lines[1]).toMatch(/ {2}│$/); - }); -}); diff --git a/nemoclaw/src/banner.ts b/nemoclaw/src/banner.ts index a2114401d64..d8f65e0e64f 100644 --- a/nemoclaw/src/banner.ts +++ b/nemoclaw/src/banner.ts @@ -1,53 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -/** A banner content row; null renders as a blank separator row. */ -export type BannerLine = string | null; +// sourceOfTruth: nemoclaw/src/shared/banner-boundary.cts +// This package entry wrapper keeps the ./banner.js import path stable for +// nemoclaw/src/index.ts while the renderer lives in the shared .cts boundary. +import { renderBox as canonicalRenderBox } from "./shared/banner-boundary.cjs"; -/** Options for rendering a Unicode terminal banner box. */ -export interface RenderBoxOptions { - /** Minimum inner box width, excluding borders. */ - minInner?: number; - /** Terminal width to respect. Defaults to process.stdout.columns, then 100. */ - columns?: number; -} +export type { BannerLine, RenderBoxOptions } from "./shared/banner-boundary.cjs"; -/** - * Render content lines inside a dynamically-sized Unicode box. - * - * The renderer expands to fit long content when the terminal is wide enough and - * otherwise truncates content while preserving a two-space safety gap before the - * closing border. That gap prevents terminal link detectors from treating the - * box-drawing border as part of long URLs or endpoints. - */ -export function renderBox( - lines: BannerLine[], - { minInner = 53, columns }: RenderBoxOptions = {}, -): string[] { - const detectedColumns = columns ?? process.stdout.columns; - const terminalColumns = - Number.isFinite(detectedColumns) && detectedColumns > 0 ? detectedColumns : 100; - const maxInner = Math.max(0, Math.floor(terminalColumns) - 4); - const contentInner = lines.reduce( - (max, line) => (line === null ? max : Math.max(max, line.length + 2)), - minInner, - ); - const inner = Math.min(maxInner, Math.max(0, contentInner)); - - const pad = (line: string): string => { - if (line.length > inner) { - if (inner <= 2) return " ".repeat(inner); - return `${line.slice(0, inner - 2)} `; - } - return line + " ".repeat(inner - line.length); - }; - - const hBar = "─".repeat(inner); - const blank = " ".repeat(inner); - - return [ - ` ┌${hBar}┐`, - ...lines.map((line) => (line === null ? ` │${blank}│` : ` │${pad(line)}│`)), - ` └${hBar}┘`, - ]; -} +export const renderBox = canonicalRenderBox; diff --git a/nemoclaw/src/shared/banner-boundary.cts b/nemoclaw/src/shared/banner-boundary.cts new file mode 100644 index 00000000000..63953a94b4f --- /dev/null +++ b/nemoclaw/src/shared/banner-boundary.cts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// sourceOfTruth: This is the one implementation of the terminal banner box +// renderer. It is compiled to generated .cjs/.d.cts files by build:cli before +// both the plugin and root CLI are built. +// consumers: The root CLI re-exports renderBox through src/lib/cli/banner.ts +// for src/lib/tunnel/services.ts; the ESM plugin re-exports it through +// nemoclaw/src/banner.ts for nemoclaw/src/index.ts. Keeping one renderer +// prevents the drift already observed between the two former copies. +// sourceBoundary: Callers own content safety; this renderer only sizes and +// truncates the box. It does not escape terminal control sequences. +// regressionTest: nemoclaw/src/shared/banner-boundary.test.ts covers the +// renderer directly; test/package-contract/banner-boundary.test.ts proves both +// built package wrappers resolve to this one generated function. +// removalCondition: remove only when a single package renders the banner. + +/** A banner content row; null renders as a blank separator row. */ +export type BannerLine = string | null; + +/** Options for rendering a Unicode terminal banner box. */ +export interface RenderBoxOptions { + /** Minimum inner box width, excluding borders. */ + minInner?: number; + /** Terminal width to respect. Defaults to process.stdout.columns, then 100. */ + columns?: number; +} + +/** + * Render content lines inside a dynamically-sized Unicode box. Long content + * expands the box when the terminal is wide enough, otherwise it is truncated + * with a two-space safety gap before the closing border so terminal link + * detectors do not treat the border as part of a long URL or endpoint. + */ +export function renderBox( + lines: BannerLine[], + { minInner = 53, columns }: RenderBoxOptions = {}, +): string[] { + const detectedColumns = columns ?? process.stdout.columns; + const terminalColumns = + Number.isFinite(detectedColumns) && detectedColumns > 0 ? detectedColumns : 100; + const maxInner = Math.max(0, Math.floor(terminalColumns) - 4); + const contentInner = lines.reduce( + (max, line) => (line === null ? max : Math.max(max, line.length + 2)), + minInner, + ); + const inner = Math.min(maxInner, Math.max(0, contentInner)); + + const pad = (line: string): string => { + if (line.length > inner) { + if (inner <= 2) return " ".repeat(inner); + return `${line.slice(0, inner - 2)} `; + } + return line + " ".repeat(inner - line.length); + }; + + const hBar = "─".repeat(inner); + const blank = " ".repeat(inner); + + return [ + ` ┌${hBar}┐`, + ...lines.map((line) => (line === null ? ` │${blank}│` : ` │${pad(line)}│`)), + ` └${hBar}┘`, + ]; +} diff --git a/src/lib/cli/banner.test.ts b/nemoclaw/src/shared/banner-boundary.test.ts similarity index 76% rename from src/lib/cli/banner.test.ts rename to nemoclaw/src/shared/banner-boundary.test.ts index 5207e1a6cef..3ee2fe13d33 100644 --- a/src/lib/cli/banner.test.ts +++ b/nemoclaw/src/shared/banner-boundary.test.ts @@ -2,43 +2,42 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, expect, it } from "vitest"; -import { renderBox } from "./banner"; +import { renderBox } from "./banner-boundary.cjs"; +// Direct contract test for the one shared renderer (replaced the two per-package parity suites). describe("renderBox", () => { it("renders a default-width box", () => { const lines = renderBox([" Hello"], { columns: 100 }); - expect(lines).toHaveLength(3); expect(lines[0]).toMatch(/^ ┌─+┐$/); expect(lines[2]).toMatch(/^ └─+┘$/); expect(lines[0].length).toBeGreaterThanOrEqual(57); }); + it("renders equal-width rows with a blank separator for null entries", () => { + const lines = renderBox( + [" NemoClaw registered", null, " Endpoint: https://integrate.api.nvidia.com/v1"], + { columns: 100 }, + ); + expect(new Set(lines.map((line) => line.length)).size).toBe(1); + expect(lines[2]).toMatch(/^ │ +│$/); + }); + it("expands for long URLs and leaves a safety gap before the border", () => { const url = " Public URL: https://very-long-subdomain-name.trycloudflare.com"; const lines = renderBox([url], { columns: 120 }); - expect(lines[1]).toContain("very-long-subdomain-name.trycloudflare.com"); expect(lines[1]).toMatch(/ {2}│$/); }); - it("caps every row at terminal columns while preserving URL safety gap", () => { + it("caps every row at terminal columns while preserving the safety gap", () => { const lines = renderBox(["x".repeat(200)], { columns: 80 }); - expect(lines.every((line) => line.length <= 80)).toBe(true); expect(lines[1]).toMatch(/ {2}│$/); }); - it("renders null entries as blank separator rows", () => { - const lines = renderBox([" Title", null, " Content"], { columns: 100 }); - - expect(lines[2]).toMatch(/^ │ +│$/); - expect(new Set(lines.map((line) => line.length)).size).toBe(1); - }); - it("handles very narrow terminals without overflowing", () => { const lines = renderBox(["abcdef"], { columns: 5 }); - expect(lines.every((line) => line.length <= 5)).toBe(true); expect(lines[1]).toBe(" │ │"); }); diff --git a/nemoclaw/tsconfig.shared.json b/nemoclaw/tsconfig.shared.json index 68578e9e315..901561e8c9d 100644 --- a/nemoclaw/tsconfig.shared.json +++ b/nemoclaw/tsconfig.shared.json @@ -5,6 +5,7 @@ "rootDir": "src" }, "include": [ + "src/shared/banner-boundary.cts", "src/shared/openshell-policy-boundary.cts", "src/shared/sandbox-name.cts", "src/shared/snapshot-sanitizer-boundary.cts" diff --git a/nemoclaw/vitest.project.ts b/nemoclaw/vitest.project.ts index 1ee0e02bea5..43d3836abdf 100644 --- a/nemoclaw/vitest.project.ts +++ b/nemoclaw/vitest.project.ts @@ -4,6 +4,7 @@ import path from "node:path"; const repositoryRoot = path.resolve(import.meta.dirname, ".."); +const canonicalBannerBoundary = path.resolve(import.meta.dirname, "src/shared/banner-boundary.cts"); const canonicalOpenShellPolicyBoundary = path.resolve( import.meta.dirname, "src/shared/openshell-policy-boundary.cts", @@ -43,6 +44,10 @@ const pluginVitestProjectOptions = { // plugin tests exercise the single source of truth rather than a // possibly-stale build artifact. alias: [ + { + find: /^.*banner-boundary\.cjs$/, + replacement: canonicalBannerBoundary, + }, { find: /^.*openshell-policy-boundary\.cjs$/, replacement: canonicalOpenShellPolicyBoundary, diff --git a/src/lib/cli/banner.ts b/src/lib/cli/banner.ts index 593f7f3725e..507b2a1dcb8 100644 --- a/src/lib/cli/banner.ts +++ b/src/lib/cli/banner.ts @@ -1,53 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -/** A banner content row; null renders as a blank separator row. */ -export type BannerLine = string | null; +// sourceOfTruth: nemoclaw/src/shared/banner-boundary.cts +// generatedBoundary: build:cli emits the canonical .cjs/.d.cts before this +// module is compiled (mirrors src/lib/policy/merge.ts). Keep this file +// implementation-free. It keeps the ../cli/banner import path stable for +// src/lib/tunnel/services.ts. +import { renderBox as canonicalRenderBox } from "../../../nemoclaw/dist/shared/banner-boundary.cjs"; -/** Options for rendering a Unicode terminal banner box. */ -export interface RenderBoxOptions { - /** Minimum inner box width, excluding borders. */ - minInner?: number; - /** Terminal width to respect. Defaults to process.stdout.columns, then 100. */ - columns?: number; -} +export type { + BannerLine, + RenderBoxOptions, +} from "../../../nemoclaw/dist/shared/banner-boundary.cjs"; -/** - * Render content lines inside a dynamically-sized Unicode box. - * - * The renderer expands to fit long content when the terminal is wide enough and - * otherwise truncates content while preserving a two-space safety gap before the - * closing border. That gap prevents terminal link detectors from treating the - * box-drawing border as part of long URLs such as trycloudflare.com links. - */ -export function renderBox( - lines: BannerLine[], - { minInner = 53, columns }: RenderBoxOptions = {}, -): string[] { - const detectedColumns = columns ?? process.stdout.columns; - const terminalColumns = - Number.isFinite(detectedColumns) && detectedColumns > 0 ? detectedColumns : 100; - const maxInner = Math.max(0, Math.floor(terminalColumns) - 4); - const contentInner = lines.reduce( - (max, line) => (line === null ? max : Math.max(max, line.length + 2)), - minInner, - ); - const inner = Math.min(maxInner, Math.max(0, contentInner)); - - const pad = (line: string): string => { - if (line.length > inner) { - if (inner <= 2) return " ".repeat(inner); - return `${line.slice(0, inner - 2)} `; - } - return line + " ".repeat(inner - line.length); - }; - - const hBar = "─".repeat(inner); - const blank = " ".repeat(inner); - - return [ - ` ┌${hBar}┐`, - ...lines.map((line) => (line === null ? ` │${blank}│` : ` │${pad(line)}│`)), - ` └${hBar}┘`, - ]; -} +export const renderBox = canonicalRenderBox; diff --git a/test/package-contract/banner-boundary.test.ts b/test/package-contract/banner-boundary.test.ts new file mode 100644 index 00000000000..786f6df1981 --- /dev/null +++ b/test/package-contract/banner-boundary.test.ts @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const repoRoot = path.join(import.meta.dirname, "..", ".."); +const url = (...segments: string[]) => pathToFileURL(path.join(repoRoot, ...segments)).href; + +describe("banner boundary package contract", () => { + it("resolves both built package wrappers to the one generated boundary function", () => { + // Native subprocess: only native resolution bypasses the Vitest source alias + // (which maps *banner-boundary.cjs to .cts) to compare the real shipped dist. + const script = + `const cli = await import(${JSON.stringify(url("dist/lib/cli/banner.js"))});` + + `const plugin = await import(${JSON.stringify(url("nemoclaw/dist/banner.js"))});` + + `const b = await import(${JSON.stringify(url("nemoclaw/dist/shared/banner-boundary.cjs"))});` + + `const cliRenderBox = cli.renderBox ?? cli.default.renderBox;` + + `process.stdout.write(JSON.stringify([cliRenderBox === b.renderBox, plugin.renderBox === b.renderBox, cliRenderBox(["abcdef"], { columns: 5 })]));`; + const output = execFileSync(process.execPath, ["--input-type=module", "-e", script], { + cwd: repoRoot, + encoding: "utf8", + timeout: 30_000, + }); + expect(JSON.parse(output)).toEqual([true, true, [" ┌─┐", " │ │", " └─┘"]]); + }); + + it("ships the generated canonical CJS boundary and its declaration", () => { + const sharedDir = path.join(repoRoot, "nemoclaw", "dist", "shared"); + expect(fs.existsSync(path.join(sharedDir, "banner-boundary.cjs"))).toBe(true); + expect(fs.existsSync(path.join(sharedDir, "banner-boundary.d.cts"))).toBe(true); + expect(fs.existsSync(path.join(sharedDir, "banner-boundary.js"))).toBe(false); + }); +}); diff --git a/test/plugin-vitest-project.test.ts b/test/plugin-vitest-project.test.ts index 40b37af1829..e0e0be23212 100644 --- a/test/plugin-vitest-project.test.ts +++ b/test/plugin-vitest-project.test.ts @@ -57,6 +57,10 @@ describe("plugin Vitest project contract", () => { expect(pluginVitestProjectOptions.test.setupFiles).toEqual([fixtureUmaskSetup]); expect(pluginVitestProjectOptions.test.include).toEqual(["nemoclaw/src/**/*.test.ts"]); expect(policyAliases).toEqual([ + { + find: /^.*banner-boundary\.cjs$/, + replacement: path.join(repositoryRoot, "nemoclaw/src/shared/banner-boundary.cts"), + }, { find: /^.*openshell-policy-boundary\.cjs$/, replacement: path.join(repositoryRoot, "nemoclaw/src/shared/openshell-policy-boundary.cts"), diff --git a/vitest.config.ts b/vitest.config.ts index 8ef0310fc41..74b747d6718 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,6 +20,7 @@ import { vitestWatchTriggerPatterns } from "./test/helpers/vitest-watch-triggers const { isCi, silent } = resolveVitestFeedback(); const LIVE_E2E_PROJECT_TIMEOUT_MS = 30 * 60 * 1000; const runLiveE2E = shouldRunLiveE2E(); +const canonicalBannerBoundary = path.resolve("nemoclaw/src/shared/banner-boundary.cts"); const canonicalOpenShellPolicyBoundary = path.resolve( "nemoclaw/src/shared/openshell-policy-boundary.cts", ); @@ -31,6 +32,10 @@ const canonicalSnapshotSanitizerBoundary = path.resolve( // source-mode test projects exercise the single source of truth rather than a // possibly-stale build artifact. const canonicalSourceAliases = [ + { + find: /^.*banner-boundary\.cjs$/, + replacement: canonicalBannerBoundary, + }, { find: /^.*openshell-policy-boundary\.cjs$/, replacement: canonicalOpenShellPolicyBoundary,