From 23ee79cb46a1654d7d8b7c1bac8c77e597aad93d Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Wed, 22 Jul 2026 09:27:21 +0300 Subject: [PATCH 1/4] fix interactive menu --- README.md | 109 +++++++++++++++++ package.json | 3 + src/cli.ts | 9 +- src/commands.ts | 9 +- src/index.ts | 227 +++++++++++++++++++++++------------ test/paidComputeFlow.test.ts | 2 +- test/replMenu.test.ts | 128 ++++++++++++++++++++ 7 files changed, 403 insertions(+), 84 deletions(-) create mode 100644 test/replMenu.test.ts diff --git a/README.md b/README.md index e3f54bfe..3ac2f7e8 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,10 @@ Instead of a DID, you can pass a full `ComputeAsset` (datasets) or `ComputeAlgor `npm run cli getComputeEnvironments` + Optionally pass a specific Ocean Node URL or peer id to query instead of `NODE_URL`: + + `npm run cli getComputeEnvironments ` + --- **Get Compute Streamable Logs:** @@ -431,6 +435,80 @@ Instead of a DID, you can pass a full `ComputeAsset` (datasets) or `ComputeAlgor --- +**Allow Algorithm on Dataset:** + +- **Positional:** + `npm run cli allowAlgo did:op:dataset did:op:algo` + +- **Named Options:** + `npm run cli allowAlgo --dataset did:op:dataset --algo did:op:algo --encrypt true` + +- Approves an algorithm to run on a compute-enabled dataset (signer must be the dataset NFT owner). + +--- + +**Create Bucket:** + + `npm run cli createBucket` + +- Creates a new persistent-storage bucket on the node. Pass an access-list contract address to gate it; omit for owner-only access: + + `npm run cli createBucket 0x1234...accessListAddress` + +--- + +**Add File to Bucket:** + + `npm run cli addFileToBucket ./path/to/file.csv` + +- Optionally pass a name to store the file under (defaults to the file's basename): + + `npm run cli addFileToBucket ./path/to/file.csv results.csv` + +--- + +**List Buckets:** + + `npm run cli listBuckets` + +- Lists buckets owned by the signer, or by a specific owner: + + `npm run cli listBuckets --owner 0x1234...ownerAddress` + +--- + +**List Files in Bucket:** + + `npm run cli listFilesInBucket ` + +--- + +**Get File Object:** + + `npm run cli getFileObject ` + +- Returns the file-object descriptor for a file in a bucket. + +--- + +**Delete File:** + + `npm run cli deleteFile ` + +--- + +**Download Node Logs (admin):** + +- **Positional:** + `npm run cli downloadNodeLogs ./logs 24` + +- **Named Options:** + `npm run cli downloadNodeLogs --output ./logs --last 24` + +- Downloads node logs into `/logs.json`. Use either `last` (hours from now) **or** a `from`/`to` epoch-ms range. `maxLogs` caps the number of entries (default: 100, max: 1000). Requires admin privileges on the node. + +--- + #### Available Named Options Per Command - **getDDO:** @@ -454,6 +532,11 @@ Instead of a DID, you can pass a full `ComputeAsset` (datasets) or `ComputeAlgor `-f, --folder [destinationFolder]` (Default: `.`) `-s, --service ` (Optional, target a specific service) +- **allowAlgo:** + `-d, --dataset ` + `-a, --algo ` + `-e, --encrypt [boolean]` (Default: `true`) + - **startCompute:** `-d, --datasets ` @@ -477,6 +560,7 @@ Instead of a DID, you can pass a full `ComputeAsset` (datasets) or `ComputeAlgor `-x, --algo-service [algoServiceId]` (Optional, override algorithm service) - **getComputeEnvironments:** + `-n, --node [node]` (Optional. Ocean Node URL or peer id to query; defaults to `NODE_URL`) - **computeStreamableLogs:** @@ -544,6 +628,31 @@ Instead of a DID, you can pass a full `ComputeAsset` (datasets) or `ComputeAlgor `-a, --address ` `-u, --users ` +- **createBucket:** + Positional only: `[accessListAddress]` (optional; omit for owner-only access) + +- **addFileToBucket:** + Positional only: ` [fileName]` + +- **listBuckets:** + `-o, --owner
` (Optional; defaults to signer) + +- **listFilesInBucket:** + Positional only: `` + +- **getFileObject:** + Positional only: ` ` + +- **deleteFile:** + Positional only: ` ` + +- **downloadNodeLogs:** + `-o, --output ` + `-l, --last [last]` (Hours from now; use either `last` or `from`/`to`) + `-f, --from [from]` (Start time, epoch ms) + `-t, --to [to]` (End time, epoch ms) + `-m, --maxLogs [maxLogs]` (Default: 100, max: 1000) + --- **Note:** diff --git a/package.json b/package.json index 9fa78750..f848b59e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ }, "author": "Ocean Protocol ", "license": "Apache-2.0", + "engines": { + "node": ">=22" + }, "bugs": { "url": "https://github.com/oceanprotocol/ocean.js-cli/issues" }, diff --git a/src/cli.ts b/src/cli.ts index 0d1dd548..2b74e47e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -466,11 +466,16 @@ export async function createCLI() { program .command("getComputeEnvironments") .alias("getC2DEnvs") + .argument( + "[node]", + "Optional Ocean Node URL or peer id to query (defaults to NODE_URL)" + ) + .option("-n, --node [node]", "Ocean Node URL or peer id to query") .description("Gets the existing compute environments") - .action(async () => { + .action(async (node, options) => { const { signer, chainId } = await initializeSigner(); const commands = new Commands(signer, chainId); - await commands.getComputeEnvironments(); + await commands.getComputeEnvironments(options.node || node); }); // computeStreamableLogs command diff --git a/src/commands.ts b/src/commands.ts index ae1fbb3d..06a513e6 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1140,10 +1140,9 @@ export class Commands { console.log(jobStatus); } - public async getComputeEnvironments() { - const computeEnvs = await ProviderInstance.getComputeEnvironments( - this.oceanNodeUrl - ); + public async getComputeEnvironments(nodeUrlOverride?: string) { + const nodeUrl = nodeUrlOverride || this.oceanNodeUrl; + const computeEnvs = await ProviderInstance.getComputeEnvironments(nodeUrl); if (!computeEnvs || computeEnvs.length < 1) { console.error( @@ -1152,7 +1151,7 @@ export class Commands { return; } - console.log("Exiting compute environments: ", JSON.stringify(computeEnvs)); + console.log("Existing compute environments: ", JSON.stringify(computeEnvs)); } public async computeStreamableLogs(args: string[]) { diff --git a/src/index.ts b/src/index.ts index ff59a02b..e6e55659 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,110 +1,185 @@ -import { sleep } from '@oceanprotocol/lib'; -import {createInterface} from "readline"; +import { Command, CommanderError } from "commander"; +import { stdin as input, stdout as output } from "node:process"; +import { createInterface } from "readline/promises"; import { createCLI } from './cli.js'; let program -let exit = false -const supportedCommands: string[] = [] -const initializeCommands: string[] = [] - -async function waitForCommands() { - const commandLine = await readLine("Enter command ('exit' | 'quit' or CTRL-C to terminate'):\n") - let command = null - if(commandLine === "quit" || commandLine === "exit" || commandLine === "\\q") { - exit = true - return - } - - const commandSplitted: string[] = commandLine.split(" ") - if(commandSplitted.length < 1) { - console.log("Invalid command, missing one or more arguments!") - return - } - - if(commandSplitted.length>=3) { - if(commandSplitted[0] === "npm" && commandSplitted[1] === "run" && commandSplitted[2] === "cli") { - commandSplitted.splice(0,3) - command = commandSplitted.join(" ") +const supportedCommands: string[] = [] + +/** + * Make Commander throw instead of calling process.exit on errors / help / + * version. This must be applied to every subcommand, not just the root program: + * a subcommand parse error (e.g. a missing required argument) is raised by the + * subcommand itself, which would otherwise kill the whole REPL process. + */ +function enableExitOverride(cmd: Command): void { + cmd.exitOverride() + for (const sub of cmd.commands) enableExitOverride(sub) +} + +/** + * Split a command line into tokens while honoring single/double quotes, so that + * JSON arguments (e.g. startCompute resources/datasets) survive intact. Runs of + * whitespace collapse and produce no empty tokens. + */ +function tokenize(line: string): string[] { + const tokens: string[] = [] + let current = "" + let quote: string | null = null + let hasContent = false + + for (const ch of line) { + if (quote) { + if (ch === quote) { + quote = null + } else { + current += ch + } + } else if (ch === '"' || ch === "'") { + quote = ch + hasContent = true + } else if (ch === " " || ch === "\t") { + if (hasContent) { + tokens.push(current) + current = "" + hasContent = false + } + } else { + current += ch + hasContent = true + } + } + if (hasContent) tokens.push(current) + return tokens +} + +/** + * Strip an optional leading `npm run cli` prefix so that a pasted example + * command behaves identically to the bare command form. + */ +function stripNpmPrefix(tokens: string[]): string[] { + if (tokens[0] === "npm" && tokens[1] === "run" && tokens[2] === "cli") { + return tokens.slice(3) } - } else if(commandSplitted.length >= 1) { - // just the command without npm run cli - command = commandSplitted[0] - } + return tokens +} - if(command && command.length > 0 && commandSplitted.length>0) { - const commandName = commandSplitted[0] - const args = initializeCommands.concat(commandSplitted) +/** + * Run a single already-tokenized command through commander. `from: 'user'` + * tells commander the tokens are raw user args (no node/script prefix), so bare + * start and initial-command start behave identically. Errors never terminate the + * REPL: commander parse errors / help / version already write their own output, + * so only unexpected runtime (action) errors are surfaced here. + */ +async function runTokens(tokens: string[]): Promise { + if (tokens.length === 0) return - if(!supportedCommands.includes(commandName)) { - console.log("Invalid option: ", commandName) + // Let commander handle option-style tokens (e.g. --help, --version); only + // reject unknown command names. + const commandName = tokens[0] + if (!commandName.startsWith("-") && !supportedCommands.includes(commandName)) { + console.log(`Invalid option: ${commandName}. Type 'help' to see the available commands.`) return - } - + } + try { - await program.parseAsync(args); - }catch(error) { - console.log('Command error: ', error) + await program.parseAsync(tokens, { from: "user" }) + } catch (error) { + // CommanderError (missing/excess args, unknown option, help, version) is + // already reported by commander itself — don't double-print it. + if (!(error instanceof CommanderError)) { + console.error("Command error:", error?.message ?? error) + } } - } - } -async function readLine(question: string): Promise { +const PROMPT = "Enter command ('exit' | 'quit' or CTRL-C to terminate):\n" - const readLine = createInterface({ - input: process.stdin, - output: process.stdout - }); +/** + * Tab-completion for the command name (the first token only). readline completes + * to the longest common prefix of the matches, or lists them when there is more + * than one. Returns all known commands when the line is still empty. + */ +function completer(line: string): [string[], string] { + // Only complete the command name, not its arguments. + if (line.includes(" ")) return [[], line] + const hits = supportedCommands.filter((name) => name.startsWith(line)).sort() + return [hits, line] +} + +/** + * Read commands from stdin until the user exits or input is exhausted (EOF). + * + * A single persistent readline interface is consumed via its async iterator so + * that backpressure is respected and no buffered lines are dropped — creating a + * fresh interface per prompt silently discards piped input beyond the first + * line. The interface is paused around command execution so it never competes + * for stdin with an interface a command opens itself (e.g. the payment + * confirmation prompt in cli.ts). + */ +async function runLoop(): Promise { + const rl = createInterface({ input, output, completer }) + rl.setPrompt(PROMPT) + rl.prompt() - let answer = "" - readLine.question(question, (it: string) => { - answer = it.trim() - readLine.close() - }) - while (answer == "") { await sleep(100) } + for await (const rawLine of rl) { + const line = rawLine.trim() + + if (line === "quit" || line === "exit" || line === "\\q") { + break + } - return answer + // Empty input: re-prompt instead of busy-waiting or dropping the session. + if (line === "") { + rl.prompt() + continue + } + + const tokens = stripNpmPrefix(tokenize(line)) + rl.pause() + await runTokens(tokens) + rl.resume() + rl.prompt() + } + rl.close() } + async function main() { try { program = await createCLI(); - for(const command of program.commands) { + for (const command of program.commands) { supportedCommands.push(command.name()) - supportedCommands.push(command.alias()) + const alias = command.alias() + if (alias) supportedCommands.push(alias) } - - console.log("Type 'exit' or 'quit' or 'CTRL-C' to terminate process") + // Handle help command without initializing signer if (process.argv.includes('--help') || process.argv.includes('-h')) { program.outputHelp(); } - const initialCommandLine:string [] = process.argv - if(process.env.AVOID_LOOP_RUN !== 'true') { - - do { - program.exitOverride(); - try { - if(initializeCommands.length === 0 && initialCommandLine.length > 2) { - initializeCommands.push(initialCommandLine[0]) // node - initializeCommands.push(initialCommandLine[1]) // file path - // just once - await program.parseAsync(initialCommandLine); - } - }catch(err) { - // silently ignore - } - await waitForCommands() - }while(!exit) - } else { + if (process.env.AVOID_LOOP_RUN === 'true') { // one shot - await program.parseAsync(initialCommandLine); + await program.parseAsync(process.argv); + return } + // In loop mode, commander must throw (not exit) on any error so a bad + // command never terminates the REPL. + enableExitOverride(program) + + // Run the initial command passed on argv once (if any), surfacing errors. + const initialTokens = process.argv.slice(2) + if (initialTokens.length > 0) { + await runTokens(initialTokens) + } + + // Then loop on stdin until the user exits or input is exhausted. + await runLoop() + } catch (error) { console.error('Program Error:', error.message); - exit = true process.exit(1); } } diff --git a/test/paidComputeFlow.test.ts b/test/paidComputeFlow.test.ts index 49c7035f..a93e8f78 100644 --- a/test/paidComputeFlow.test.ts +++ b/test/paidComputeFlow.test.ts @@ -121,7 +121,7 @@ describe("Ocean CLI Paid Compute", function() { it("should get compute environments using 'npm run cli getComputeEnvironments'", async function() { const output = await runCommand(`npm run cli getComputeEnvironments`); - const jsonMatch = output.match(/Exiting compute environments:\s*([\s\S]*)/); + const jsonMatch = output.match(/Existing compute environments:\s*([\s\S]*)/); if (!jsonMatch) { console.error("Raw output:", output); throw new Error("Could not find compute environments in the output"); diff --git a/test/replMenu.test.ts b/test/replMenu.test.ts new file mode 100644 index 00000000..51b66943 --- /dev/null +++ b/test/replMenu.test.ts @@ -0,0 +1,128 @@ +import { expect } from "chai"; +import { spawn } from "child_process"; +import path from "path"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const projectRoot = path.resolve(__dirname, ".."); + +// Recurring prompt string emitted by the REPL (keep in sync with src/index.ts). +const PROMPT = "Enter command ('exit' | 'quit' or CTRL-C to terminate):\n"; + +/** + * Drive the interactive REPL (menu mode) with piped stdin. + * + * These tests are infra-free: PRIVATE_KEY/RPC/NODE_URL point at an unreachable + * port, so a command that actually parses and runs surfaces a "Command error" + * (connection refused) while a command that is dropped or rejected at parse time + * does not. AVOID_LOOP_RUN is left unset so the process enters the REPL loop. + */ +function runRepl( + inputLines: string[], + extraArgs: string[] = [] +): Promise<{ output: string; code: number | null }> { + return new Promise((resolve, reject) => { + const env = { ...process.env }; + delete env.AVOID_LOOP_RUN; + env.PRIVATE_KEY = + "0x1d751ded5a32226054cd2e71261039b65afb9ee1c746d055dd699b1150a5befc"; + env.RPC = "http://127.0.0.1:1"; + env.NODE_URL = "http://127.0.0.1:1"; + + const child = spawn("npx", ["tsx", "src/index.ts", ...extraArgs], { + cwd: projectRoot, + env, + }); + + let output = ""; + child.stdout.on("data", (d) => (output += d.toString())); + child.stderr.on("data", (d) => (output += d.toString())); + child.on("error", reject); + child.on("close", (code) => resolve({ output, code })); + + for (const line of inputLines) { + child.stdin.write(line + "\n"); + } + child.stdin.end(); + }); +} + +describe("Ocean CLI interactive menu (REPL)", function () { + this.timeout(60000); + + it("runs a single-token command on bare start", async function () { + const { output } = await runRepl(["getComputeEnvironments", "exit"]); + // Parsed and executed (fails at the network, not at parsing). + expect(output).to.contain("Command error"); + expect(output).to.not.contain("Invalid option"); + }); + + it("runs a multi-argument command instead of silently dropping it", async function () { + const { output } = await runRepl(["download did:op:123 /tmp/x", "exit"]); + expect(output).to.contain("Command error"); + expect(output).to.not.contain("Invalid option"); + }); + + it("accepts a line typed with the 'npm run cli' prefix", async function () { + const { output } = await runRepl([ + "npm run cli getComputeEnvironments", + "exit", + ]); + expect(output).to.contain("Command error"); + expect(output).to.not.contain("Invalid option"); + }); + + it("keeps a single-quoted JSON argument as one token", async function () { + const { output } = await runRepl([ + `editAsset did:op:1 '{"name": "a b c"}'`, + "exit", + ]); + // If quoting were mishandled the JSON would split into extra tokens and + // Commander would reject it with "too many arguments". + expect(output).to.not.contain("too many arguments"); + expect(output).to.contain("Command error"); + }); + + it("re-prompts on empty input instead of hanging", async function () { + const { output, code } = await runRepl(["", "", "exit"]); + // Reaching this assertion at all means it did not hang (mocha would time out). + expect(code).to.equal(0); + const prompts = output.split(PROMPT).length - 1; + expect(prompts).to.be.greaterThanOrEqual(3); + }); + + it("reports an unknown command and suggests help", async function () { + const { output } = await runRepl(["definitelyNotACommand", "exit"]); + expect(output).to.contain("Invalid option: definitelyNotACommand"); + }); + + it("survives a command with a missing required argument", async function () { + // getDDO requires ; the parse error must not terminate the REPL, and a + // subsequent command must still run. + const { output, code } = await runRepl([ + "getDDO", + "definitelyNotACommand", + "exit", + ]); + expect(output).to.contain("missing required argument"); + // Proof the loop kept going after the parse error: + expect(output).to.contain("Invalid option: definitelyNotACommand"); + expect(code).to.equal(0); + }); + + it("exits cleanly on EOF when 'exit' is never typed", async function () { + const { code } = await runRepl(["getComputeEnvironments"]); + expect(code).to.equal(0); + }); + + it("accepts an optional node argument for getComputeEnvironments", async function () { + const { output } = await runRepl([ + "getComputeEnvironments http://127.0.0.1:2", + "exit", + ]); + expect(output).to.not.contain("too many arguments"); + expect(output).to.contain("Command error"); + }); +}); From 68beb7cb96a3e258dbf4ade99e953bf77023444c Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Wed, 22 Jul 2026 10:15:02 +0300 Subject: [PATCH 2/4] fix comments --- README.md | 6 ++++-- src/cli.ts | 2 +- src/index.ts | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3ac2f7e8..00159167 100644 --- a/README.md +++ b/README.md @@ -440,8 +440,10 @@ Instead of a DID, you can pass a full `ComputeAsset` (datasets) or `ComputeAlgor - **Positional:** `npm run cli allowAlgo did:op:dataset did:op:algo` -- **Named Options:** - `npm run cli allowAlgo --dataset did:op:dataset --algo did:op:algo --encrypt true` +- **With named option:** + `npm run cli allowAlgo did:op:dataset did:op:algo --encrypt true` + +- The dataset and algorithm DIDs are required positional arguments (`--dataset` / `--algo` may override them, but the positionals must still be supplied). `--encrypt` toggles DDO encryption (default: `true`). - Approves an algorithm to run on a compute-enabled dataset (signer must be the dataset NFT owner). diff --git a/src/cli.ts b/src/cli.ts index 2b74e47e..89d98788 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -470,7 +470,7 @@ export async function createCLI() { "[node]", "Optional Ocean Node URL or peer id to query (defaults to NODE_URL)" ) - .option("-n, --node [node]", "Ocean Node URL or peer id to query") + .option("-n, --node ", "Ocean Node URL or peer id to query") .description("Gets the existing compute environments") .action(async (node, options) => { const { signer, chainId } = await initializeSigner(); diff --git a/src/index.ts b/src/index.ts index e6e55659..921be2bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -154,9 +154,11 @@ async function main() { if (alias) supportedCommands.push(alias) } - // Handle help command without initializing signer + // Handle help flag without initializing signer, and exit so it prints + // once (not again via parseAsync/runLoop below). if (process.argv.includes('--help') || process.argv.includes('-h')) { program.outputHelp(); + return; } if (process.env.AVOID_LOOP_RUN === 'true') { From af05b40e52903bda53ac319bdf7debb5415daf1d Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Wed, 22 Jul 2026 10:23:01 +0300 Subject: [PATCH 3/4] resolve nitpicks --- src/index.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 921be2bd..ee319c1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,10 @@ import { Command, CommanderError } from "commander"; +import chalk from "chalk"; import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "readline/promises"; import { createCLI } from './cli.js'; -let program +let program: Command const supportedCommands: string[] = [] /** @@ -78,7 +79,7 @@ async function runTokens(tokens: string[]): Promise { // reject unknown command names. const commandName = tokens[0] if (!commandName.startsWith("-") && !supportedCommands.includes(commandName)) { - console.log(`Invalid option: ${commandName}. Type 'help' to see the available commands.`) + console.log(chalk.red(`Invalid option: ${commandName}. Type 'help' to see the available commands.`)) return } @@ -88,7 +89,7 @@ async function runTokens(tokens: string[]): Promise { // CommanderError (missing/excess args, unknown option, help, version) is // already reported by commander itself — don't double-print it. if (!(error instanceof CommanderError)) { - console.error("Command error:", error?.message ?? error) + console.error(chalk.red(`Command error: ${error?.message ?? error}`)) } } } @@ -145,7 +146,7 @@ async function runLoop(): Promise { rl.close() } -async function main() { +async function main(): Promise { try { program = await createCLI(); for (const command of program.commands) { @@ -181,7 +182,7 @@ async function main() { await runLoop() } catch (error) { - console.error('Program Error:', error.message); + console.error(chalk.red(`Program Error: ${error.message}`)); process.exit(1); } } From 08112a98afd16e798b49218d8eabc731f99d2d26 Mon Sep 17 00:00:00 2001 From: alexcos20 Date: Wed, 22 Jul 2026 10:23:09 +0300 Subject: [PATCH 4/4] pretty format errors --- src/index.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index ee319c1d..d9c8f0f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,14 +8,18 @@ let program: Command const supportedCommands: string[] = [] /** - * Make Commander throw instead of calling process.exit on errors / help / - * version. This must be applied to every subcommand, not just the root program: - * a subcommand parse error (e.g. a missing required argument) is raised by the - * subcommand itself, which would otherwise kill the whole REPL process. + * Prepare commander for REPL use: make it throw instead of calling process.exit + * on errors / help / version, and colorize its own error output (e.g. "missing + * required argument"). Both must be applied to every subcommand, not just the + * root program: a subcommand parse error is raised and written by the subcommand + * itself, which would otherwise kill the REPL and print an uncolored message. */ -function enableExitOverride(cmd: Command): void { +function configureForLoop(cmd: Command): void { cmd.exitOverride() - for (const sub of cmd.commands) enableExitOverride(sub) + cmd.configureOutput({ + outputError: (str, write) => write(chalk.red(str)), + }) + for (const sub of cmd.commands) configureForLoop(sub) } /** @@ -169,8 +173,8 @@ async function main(): Promise { } // In loop mode, commander must throw (not exit) on any error so a bad - // command never terminates the REPL. - enableExitOverride(program) + // command never terminates the REPL, and its errors are colorized. + configureForLoop(program) // Run the initial command passed on argv once (if any), surfacing errors. const initialTokens = process.argv.slice(2)