Skip to content
Merged
207 changes: 207 additions & 0 deletions src/lib/credentials-cli-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */

Comment on lines +4 to +5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Narrow the coverage suppression scope in this file.

Line 4 starts a file-wide ignore region, but this module contains non-trivial runtime and error-path logic. Blanket suppression here will hide useful branch coverage signals.

♻️ Proposed fix
-/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/credentials-cli-command.ts` around lines 4 - 5, The file currently
begins with a file-wide "/* v8 ignore start */" which suppresses coverage for
the entire module; narrow this by removing the top-level ignore and instead wrap
the minimal thin oclif adapter code in a paired "/* v8 ignore start */" and "/*
v8 ignore end */" block (e.g., the adapter function/class for oclif in this
module such as CredentialsCliCommand or the specific adapter helper) so runtime
and error-path logic outside that adapter remains instrumented; ensure the
ignore pair is balanced and placed only around the adapter lines.

import { Args, Command, Flags } from "@oclif/core";

import { CLI_DISPLAY_NAME, CLI_NAME } from "./branding";
import { prompt as askPrompt } from "./credentials";

interface SpawnLikeResult {
status: number | null;
stdout?: string | Buffer;
stderr?: string | Buffer;
}

interface RuntimeBridge {
recoverNamedGatewayRuntime: () => Promise<{ recovered: boolean }>;
runOpenshell: (
args: string[],
opts?: { ignoreError?: boolean; stdio?: import("node:child_process").StdioOptions },
) => SpawnLikeResult;
}

// Suffixes that mark per-sandbox messaging integrations in the gateway's
// provider list. These are managed by `channels`, not `credentials`.
const BRIDGE_PROVIDER_SUFFIXES: readonly string[] = [
"-telegram-bridge",
"-discord-bridge",
"-slack-bridge",
"-slack-app",
];

function getRuntimeBridge(): RuntimeBridge {
return require("../nemoclaw") as RuntimeBridge;
}

function isBridgeProviderName(name: string): boolean {
return BRIDGE_PROVIDER_SUFFIXES.some((suffix) => name.endsWith(suffix));
}

function printCredentialsUsage(log: (message?: string) => void = console.log): void {
log("");
log(` Usage: ${CLI_NAME} credentials <subcommand>`);
log("");
log(" Subcommands:");
log(" list List provider credentials registered with the OpenShell gateway");
log(" reset <PROVIDER> [--yes] Remove a provider credential so onboard re-prompts");
log("");
log(" Credentials live in the OpenShell gateway. Inspect with `openshell provider list`.");
log(" Nothing is persisted to host disk; deploy/non-onboard commands read from env vars.");
log("");
}

async function recoverGatewayOrExit(kind: "query" | "reach"): Promise<void> {
const runtime = getRuntimeBridge();
const recovery = await runtime.recoverNamedGatewayRuntime();
if (recovery.recovered) return;

if (kind === "query") {
console.error(` Could not query the ${CLI_DISPLAY_NAME} OpenShell gateway. Is it running?`);
} else {
console.error(` Could not reach the ${CLI_DISPLAY_NAME} OpenShell gateway. Is it running?`);
}
console.error(` Run 'openshell gateway start --name nemoclaw' or '${CLI_NAME} onboard' first.`);
process.exit(1);
}

export class CredentialsCommand extends Command {
static id = "credentials";
static strict = true;
static summary = "Manage provider credentials";
static description =
"List or reset provider credentials registered with the OpenShell gateway.";
static usage = ["credentials <list|reset>"];
static flags = {
help: Flags.help({ char: "h" }),
};

public async run(): Promise<void> {
await this.parse(CredentialsCommand);
printCredentialsUsage(this.log.bind(this));
}
}

export class CredentialsListCommand extends Command {
static id = "credentials:list";
static strict = true;
static summary = "List stored credential providers";
static description = "List provider credentials registered with the OpenShell gateway.";
static usage = ["credentials list"];
static flags = {
help: Flags.help({ char: "h" }),
};

public async run(): Promise<void> {
await this.parse(CredentialsListCommand);
await recoverGatewayOrExit("query");

const runtime = getRuntimeBridge();
const result = runtime.runOpenshell(["provider", "list", "--names"], {
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
console.error(" Could not query OpenShell gateway. Is it running?");
console.error(` Run 'openshell gateway start --name nemoclaw' or '${CLI_NAME} onboard' first.`);
process.exit(1);
}

const allNames = String(result.stdout || "")
.split("\n")
.map((name) => name.trim())
.filter((name) => name.length > 0);
const credentialNames = allNames.filter((name) => !isBridgeProviderName(name)).sort();
const bridgeNames = allNames.filter((name) => isBridgeProviderName(name));

if (credentialNames.length === 0) {
this.log(" No provider credentials registered.");
} else {
this.log(" Providers registered with the OpenShell gateway:");
for (const name of credentialNames) {
this.log(` ${name}`);
}
}
if (bridgeNames.length > 0) {
this.log("");
this.log(` ${String(bridgeNames.length)} per-sandbox messaging bridge(s) are also registered.`);
this.log(` Manage those with \`${CLI_NAME} <sandbox> channels list/remove/stop\` — not this command.`);
}
}
}

export class CredentialsResetCommand extends Command {
static id = "credentials:reset";
static strict = true;
static summary = "Remove a provider credential";
static description = "Remove a provider credential so onboard re-prompts for it.";
static usage = ["credentials reset <PROVIDER> [--yes]"];
static args = {
provider: Args.string({
name: "PROVIDER",
description: "OpenShell provider name",
required: false,
}),
};
static flags = {
help: Flags.help({ char: "h" }),
yes: Flags.boolean({ char: "y", description: "Skip the confirmation prompt" }),
};

public async run(): Promise<void> {
const { args, flags } = await this.parse(CredentialsResetCommand);
const key = args.provider;

if (!key || key.startsWith("-")) {
console.error(` Usage: ${CLI_NAME} credentials reset <PROVIDER> [--yes]`);
console.error(` PROVIDER is an OpenShell provider name. Run '${CLI_NAME} credentials list' first.`);
process.exit(1);
}

if (isBridgeProviderName(key)) {
console.error(` '${key}' is a per-sandbox messaging bridge, not a credential.`);
console.error(
` Use \`${CLI_NAME} <sandbox> channels remove <telegram|discord|slack>\` to retire`,
);
console.error(" the integration (it tears down the bridge provider and rebuilds the sandbox),");
console.error(` or \`${CLI_NAME} <sandbox> channels stop <…>\` to pause it without clearing tokens.`);
process.exit(1);
}

if (!flags.yes) {
const answer = (await askPrompt(` Remove provider '${key}' from the OpenShell gateway? [y/N]: `))
.trim()
.toLowerCase();
if (answer !== "y" && answer !== "yes") {
this.log(" Cancelled.");
return;
}
}

await recoverGatewayOrExit("reach");

const runtime = getRuntimeBridge();
const result = runtime.runOpenshell(["provider", "delete", key], {
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status === 0) {
this.log(` Removed provider '${key}' from the OpenShell gateway.`);
this.log(` Re-run '${CLI_NAME} onboard' to enter a new value.`);
return;
}

console.error(` Could not remove provider '${key}'.`);
if (/^[A-Z][A-Z0-9_]+$/.test(key)) {
console.error("");
console.error(` '${key}' looks like a credential env variable name.`);
console.error(" As of this release, 'credentials reset' takes an OpenShell");
console.error(` provider name. Run '${CLI_NAME} credentials list' to see the`);
console.error(" registered providers, then retry with one of those names.");
}
const stderr = String(result.stderr || "").trim();
if (stderr) console.error(` ${stderr}`);
process.exit(1);
}
}
82 changes: 82 additions & 0 deletions src/lib/debug-cli-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */

Comment on lines +4 to +5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Avoid file-wide coverage ignore for behaviorful debug logic.

Line 4 suppresses coverage for the entire module, including default-sandbox resolution and OpenShell probe warning paths. That makes regressions harder to catch in this command.

♻️ Proposed fix
-/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/debug-cli-command.ts` around lines 4 - 5, Remove the file-wide
coverage ignore comment (the "v8 ignore start" banner) from
src/lib/debug-cli-command.ts and narrow coverage suppressions only to the
specific thin oclif adapter bits that truly cannot be unit-tested; leave the
default-sandbox resolution flow and the OpenShell probe warning code covered by
tests. Concretely, delete the global ignore comment and instead add targeted
ignore markers only around the thin oclif adapter bootstrap lines (the small
adapter block) so functions handling default-sandbox resolution and the
OpenShell probe warning remain instrumented and testable.

import { Command } from "@oclif/core";

import type { CaptureOpenshellResult } from "./openshell";
import type { RunDebugCommandDeps } from "./debug-command";
import { CLI_NAME } from "./branding";
import { runDebug } from "./debug";
import { runDebugCommand } from "./debug-command";
import { OPENSHELL_PROBE_TIMEOUT_MS } from "./openshell-timeouts";
import { captureOpenshellCommand } from "./openshell";
import { parseLiveSandboxNames } from "./runtime-recovery";
import * as registry from "./registry";
import { resolveOpenshell } from "./resolve-openshell";

const useColor = !process.env.NO_COLOR && !!process.stderr.isTTY;
const B = useColor ? "\x1b[1m" : "";
const R = useColor ? "\x1b[0m" : "";
const RD = useColor ? "\x1b[1;31m" : "";

function captureOpenshell(rootDir: string, args: string[]): CaptureOpenshellResult {
const openshell = resolveOpenshell();
if (!openshell) {
return { status: 1, output: "" };
}
return captureOpenshellCommand(openshell, args, {
cwd: rootDir,
ignoreError: true,
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
});
}

function buildDebugCommandDeps(rootDir: string): RunDebugCommandDeps {
const getDefaultSandbox = (): string | undefined => {
const { defaultSandbox, sandboxes } = registry.listSandboxes();
if (!defaultSandbox) return undefined;
if (!sandboxes.find((sandbox) => sandbox.name === defaultSandbox)) {
console.error(
`${RD}Warning:${R} default sandbox '${defaultSandbox}' is no longer in the registry.`,
);
console.error(
` Use ${B}--sandbox NAME${R} to target a specific sandbox, or run ${B}${CLI_NAME} onboard${R} again.\n`,
);
return undefined;
}
const liveList = captureOpenshell(rootDir, ["sandbox", "list"]);
if (liveList.status === 0 && !parseLiveSandboxNames(liveList.output).has(defaultSandbox)) {
console.error(
`${RD}Warning:${R} default sandbox '${defaultSandbox}' exists in the local registry but not in OpenShell.`,
);
console.error(
` Use ${B}--sandbox NAME${R} to target a specific sandbox, or run ${B}${CLI_NAME} onboard${R} again.\n`,
);
return undefined;
}
return defaultSandbox;
};

return {
getDefaultSandbox,
runDebug,
log: console.log,
error: console.error,
exit: (code: number) => process.exit(code),
};
}

export default class DebugCliCommand extends Command {
static id = "debug";
static strict = false;
static summary = "Collect diagnostics for bug reports";
static description = "Collect NemoClaw diagnostic information.";
static usage = ["debug [--quick] [--output FILE] [--sandbox NAME]"];

public async run(): Promise<void> {
this.parsed = true;
runDebugCommand(this.argv, buildDebugCommandDeps(this.config.root));
}
}
47 changes: 47 additions & 0 deletions src/lib/gateway-token-cli-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */

import { Args, Command, Flags } from "@oclif/core";

import { runGatewayTokenCommand } from "./gateway-token-command";

const { fetchGatewayAuthTokenFromSandbox } = require("./onboard") as {
fetchGatewayAuthTokenFromSandbox: (sandboxName: string) => string | null;
};

export default class GatewayTokenCliCommand extends Command {
static id = "sandbox:gateway-token";
static strict = true;
static summary = "Print the OpenClaw gateway auth token to stdout";
static description = "Print the OpenClaw gateway auth token for a running sandbox to stdout.";
static usage = ["<name> gateway-token [--quiet|-q]"];
static args = {
sandboxName: Args.string({
name: "sandbox",
description: "Sandbox name",
required: true,
}),
};
static flags = {
help: Flags.help({ char: "h" }),
quiet: Flags.boolean({ char: "q", description: "Suppress the stderr security warning" }),
};

public async run(): Promise<void> {
const { args, flags } = await this.parse(GatewayTokenCliCommand);
// Suppress EPIPE traces when the consumer closes the pipe early
// (e.g. `... | head -c 0`). The token has already been written.
process.stdout.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EPIPE") process.exit(0);
});

const exitCode = runGatewayTokenCommand(
args.sandboxName,
{ quiet: flags.quiet === true },
{ fetchToken: fetchGatewayAuthTokenFromSandbox },
);
if (exitCode !== 0) this.exit(exitCode);
}
}
2 changes: 2 additions & 0 deletions src/lib/list-command.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/* v8 ignore start -- thin oclif adapter covered through CLI integration tests. */

import { Command, Flags } from "@oclif/core";

import { getSandboxInventory, renderSandboxInventoryText } from "./inventory-commands";
Expand Down
Loading
Loading