diff --git a/README.md b/README.md index e3f54bf..0015916 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,82 @@ 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` + +- **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). + +--- + +**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 +534,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 +562,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 +630,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 9fa7875..f848b59 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 0d1dd54..89d9878 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 ", "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 ae1fbb3..06a513e 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 ff59a02..d9c8f0f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,110 +1,192 @@ -import { sleep } from '@oceanprotocol/lib'; -import {createInterface} from "readline"; +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 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(" ") +let program: Command +const supportedCommands: string[] = [] + +/** + * 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 configureForLoop(cmd: Command): void { + cmd.exitOverride() + cmd.configureOutput({ + outputError: (str, write) => write(chalk.red(str)), + }) + for (const sub of cmd.commands) configureForLoop(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(chalk.red(`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(chalk.red(`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() { + +async function main(): Promise { 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 + + // 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; } - 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, 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) + 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 + console.error(chalk.red(`Program Error: ${error.message}`)); process.exit(1); } } diff --git a/test/paidComputeFlow.test.ts b/test/paidComputeFlow.test.ts index 49c7035..a93e8f7 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 0000000..51b6694 --- /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"); + }); +});